package com.akdeniz.googleplaycrawler;

import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.utils.URLEncodedUtils;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.PoolingClientConnectionManager;
import org.apache.http.impl.conn.SchemeRegistryFactory;
import org.apache.http.message.BasicNameValuePair;

import com.akdeniz.googleplaycrawler.GooglePlay.AndroidAppDeliveryData;
import com.akdeniz.googleplaycrawler.GooglePlay.AndroidCheckinRequest;
import com.akdeniz.googleplaycrawler.GooglePlay.AndroidCheckinResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.BrowseResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.BulkDetailsRequest;
import com.akdeniz.googleplaycrawler.GooglePlay.BulkDetailsRequest.Builder;
import com.akdeniz.googleplaycrawler.GooglePlay.BulkDetailsResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.BuyResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.DetailsResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.HttpCookie;
import com.akdeniz.googleplaycrawler.GooglePlay.ListResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.ResponseWrapper;
import com.akdeniz.googleplaycrawler.GooglePlay.ReviewResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.SearchResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.UploadDeviceConfigRequest;
import com.akdeniz.googleplaycrawler.GooglePlay.UploadDeviceConfigResponse;

/**
 * This class provides
 * <code>checkin, search, details, bulkDetails, browse, list and download</code>
 * capabilities. It uses <code>Apache Commons HttpClient</code> for POST and GET
 * requests.
 * 
 * <p>
 * <b>XXX : DO NOT call checkin, login and download consecutively. To allow
 * server to catch up, sleep for a while before download! (5 sec will do!) Also
 * it is recommended to call checkin once and use generated android-id for
 * further operations.</b>
 * </p>
 * 
 * @author akdeniz
 * 
 */
public class GooglePlayAPI {

    private static final String CHECKIN_URL = "https://android.clients.google.com/checkin";
    private static final String URL_LOGIN = "https://android.clients.google.com/auth";
    private static final String C2DM_REGISTER_URL = "https://android.clients.google.com/c2dm/register2";
    private static final String FDFE_URL = "https://android.clients.google.com/fdfe/";
    private static final String LIST_URL = FDFE_URL + "list";
    private static final String BROWSE_URL = FDFE_URL + "browse";
    private static final String DETAILS_URL = FDFE_URL + "details";
    private static final String SEARCH_URL = FDFE_URL + "search";
    private static final String BULKDETAILS_URL = FDFE_URL + "bulkDetails";
    private static final String PURCHASE_URL = FDFE_URL + "purchase";
    private static final String REVIEWS_URL = FDFE_URL + "rev";
    private static final String UPLOADDEVICECONFIG_URL = FDFE_URL + "uploadDeviceConfig";
    private static final String RECOMMENDATIONS_URL = FDFE_URL + "rec";

    private static final String ACCOUNT_TYPE_HOSTED_OR_GOOGLE = "HOSTED_OR_GOOGLE";

    public static enum REVIEW_SORT {
	NEWEST(0), HIGHRATING(1), HELPFUL(2);

	public int value;

	private REVIEW_SORT(int value) {
	    this.value = value;
	}
    }
    
    public static enum RECOMMENDATION_TYPE {
	ALSO_VIEWED(1), ALSO_INSTALLED(2);

	public int value;

	private RECOMMENDATION_TYPE(int value) {
	    this.value = value;
	}
    }

    private String token;
    private String androidID;
    private String email;
    private String password;
    private HttpClient client;
    private String securityToken;
    private String localization;

    /**
     * Default constructor. ANDROID ID and Authentication token must be supplied
     * before any other operation.
     */
    public GooglePlayAPI() {
    }

    /**
     * Constructs a ready to login {@link GooglePlayAPI}.
     */
    public GooglePlayAPI(String email, String password, String androidID) {
	this(email, password);
	this.setAndroidID(androidID);
    }

    /**
     * If this constructor is used, Android ID must be generated by calling
     * <code>checkin()</code> or set by using <code>setAndroidID</code> before
     * using other abilities.
     */
    public GooglePlayAPI(String email, String password) {
	this.setEmail(email);
	this.password = password;
	setClient(new DefaultHttpClient(getConnectionManager()));
    }

    /**
     * Connection manager to allow concurrent connections.
     * @return {@link ClientConnectionManager} instance
     */
    public static ClientConnectionManager getConnectionManager() {
	PoolingClientConnectionManager connManager = 
		new PoolingClientConnectionManager( SchemeRegistryFactory.createDefault());
	connManager.setMaxTotal(100);
	connManager.setDefaultMaxPerRoute(30);
	return connManager;
    }

    /**
     * Performs authentication on "ac2dm" service and match up android id,
     * security token and email by checking them in on this server.
     * 
     * This function sets check-inded android ID and that can be taken either by
     * using <code>getToken()</code> or from returned
     * {@link AndroidCheckinResponse} instance.
     * 
     */
    public AndroidCheckinResponse checkin() throws Exception {

	// this first checkin is for generating android-id
	AndroidCheckinResponse checkinResponse = postCheckin(Utils.generateAndroidCheckinRequest().toByteArray());
	this.setAndroidID(BigInteger.valueOf(checkinResponse.getAndroidId()).toString(16));
	setSecurityToken((BigInteger.valueOf(checkinResponse.getSecurityToken()).toString(16)));

	String c2dmAuth = loginAC2DM();

	AndroidCheckinRequest.Builder checkInbuilder = AndroidCheckinRequest.newBuilder(Utils.generateAndroidCheckinRequest());

	AndroidCheckinRequest build = checkInbuilder.setId(new BigInteger(this.getAndroidID(), 16).longValue())
		.setSecurityToken(new BigInteger(getSecurityToken(), 16).longValue()).addAccountCookie("[" + getEmail() + "]")
		.addAccountCookie(c2dmAuth).build();
	// this is the second checkin to match credentials with android-id
	return postCheckin(build.toByteArray());
    }
    
    /**
     * Logins AC2DM server and returns authentication string.
     *
     * <p>
     * client_sig is SHA1 digest of encoded certificate on 
     * <i>GoogleLoginService(package name : com.google.android.gsf)</i> system APK. 
     * But google doesn't seem to care of value of this parameter. 
     */
    public String loginAC2DM() throws IOException{
	HttpEntity c2dmResponseEntity = executePost(URL_LOGIN, new String[][] { { "Email", this.getEmail() },
		{ "Passwd", this.password }, { "service", "ac2dm" }, { "accountType", ACCOUNT_TYPE_HOSTED_OR_GOOGLE },
		{ "has_permission", "1" }, { "source", "android" }, { "app", "com.google.android.gsf" },
		{ "device_country", "us" }, { "device_country", "us" }, { "lang", "en" }, { "sdk_version", "16" }, { "client_sig", "38918a453d07199354f8b19af05ec6562ced5788" }, }, null);

	Map<String, String> c2dmAuth = Utils.parseResponse(new String(Utils.readAll(c2dmResponseEntity.getContent())));
	return c2dmAuth.get("Auth");

    }
    
    public Map<String, String> c2dmRegister(String application, String sender) throws IOException{
	
	String c2dmAuth = loginAC2DM();
	String[][]  data = new String[][]{{"app", application},{"sender", sender}, {"device", new BigInteger(this.getAndroidID(), 16).toString()}};
	HttpEntity responseEntity = executePost(C2DM_REGISTER_URL, data, getHeaderParameters(c2dmAuth, null));
	return Utils.parseResponse(new String(Utils.readAll(responseEntity.getContent())));
    }

    /**
     * Equivalent of <code>setToken</code>. This function does not performs
     * authentication, it simply sets authentication token.
     */
    public void login(String token) throws Exception {
	setToken(token);
    }

    /**
     * Authenticates on server with given email and password and sets
     * authentication token. This token can be used to login instead of using
     * email and password every time.
     */
    public void login() throws Exception {

	HttpEntity responseEntity = executePost(URL_LOGIN, new String[][] { { "Email", this.getEmail() }, { "Passwd", this.password },
		{ "service", "androidmarket" }, { "accountType", ACCOUNT_TYPE_HOSTED_OR_GOOGLE }, { "has_permission", "1" },
		{ "source", "android" }, { "androidId", this.getAndroidID() }, { "app", "com.android.vending" },
		{ "device_country", "en" }, { "lang", "en" }, { "sdk_version", "16" }, { "client_sig", "38918a453d07199354f8b19af05ec6562ced5788" }, }, null);

	Map<String, String> response = Utils.parseResponse(new String(Utils.readAll(responseEntity.getContent())));
	if (response.containsKey("Auth")) {
	    setToken(response.get("Auth"));
	} else {
	    throw new GooglePlayException("Authentication failed!");
	}
    }

    /**
     * Equivalent of <code>search(query, null, null)</code>
     */
    public SearchResponse search(String query) throws IOException {
	return search(query, null, null);
    }

    /**
     * Fetches a search results for given query. Offset and numberOfResult
     * parameters are optional and <code>null</code> can be passed!
     */
    public SearchResponse search(String query, Integer offset, Integer numberOfResult) throws IOException {

	ResponseWrapper responseWrapper = executeGETRequest(SEARCH_URL,
		new String[][] { { "c", "3" }, { "q", query }, { "o", (offset == null) ? null : String.valueOf(offset) },
			{ "n", (numberOfResult == null) ? null : String.valueOf(numberOfResult) }, });

	return responseWrapper.getPayload().getSearchResponse();
    }

    /**
     * Fetches detailed information about passed package name. If it is needed
     * to fetch information about more than one application, consider to use
     * <code>bulkDetails</code>.
     */
    public DetailsResponse details(String packageName) throws IOException {
	ResponseWrapper responseWrapper = executeGETRequest(DETAILS_URL, new String[][] { { "doc", packageName }, });

	return responseWrapper.getPayload().getDetailsResponse();
    }

    /** Equivalent of details but bulky one! */
    public BulkDetailsResponse bulkDetails(List<String> packageNames) throws IOException {

	Builder bulkDetailsRequestBuilder = BulkDetailsRequest.newBuilder();
	bulkDetailsRequestBuilder.addAllDocid(packageNames);

	ResponseWrapper responseWrapper = executePOSTRequest(BULKDETAILS_URL, bulkDetailsRequestBuilder.build().toByteArray(),
		"application/x-protobuf");

	return responseWrapper.getPayload().getBulkDetailsResponse();
    }

    /** Fetches available categories */
    public BrowseResponse browse() throws IOException {

	return browse(null, null);
    }

    public BrowseResponse browse(String categoryId, String subCategoryId) throws IOException {

	ResponseWrapper responseWrapper = executeGETRequest(BROWSE_URL, new String[][] { { "c", "3" }, { "cat", categoryId },
		{ "ctr", subCategoryId } });

	return responseWrapper.getPayload().getBrowseResponse();
    }

    /**
     * Equivalent of <code>list(categoryId, null, null, null)</code>. It fetches
     * sub-categories of given category!
     */
    public ListResponse list(String categoryId) throws IOException {
	return list(categoryId, null, null, null);
    }

    /**
     * Fetches applications within supplied category and sub-category. If
     * <code>null</code> is given for sub-category, it fetches sub-categories of
     * passed category.
     * 
     * Default values for offset and numberOfResult are "0" and "20"
     * respectively. These values are determined by Google Play Store.
     */
    public ListResponse list(String categoryId, String subCategoryId, Integer offset, Integer numberOfResult) throws IOException {
	ResponseWrapper responseWrapper = executeGETRequest(LIST_URL, new String[][] { { "c", "3" }, { "cat", categoryId },
		{ "ctr", subCategoryId }, { "o", (offset == null) ? null : String.valueOf(offset) },
		{ "n", (numberOfResult == null) ? null : String.valueOf(numberOfResult) }, });

	return responseWrapper.getPayload().getListResponse();
    }

    /**
     * Downloads given application package name, version and offer type. Version
     * code and offer type can be fetch by <code>details</code> interface.
     **/
    public InputStream download(String packageName, int versionCode, int offerType) throws IOException {

	BuyResponse buyResponse = purchase(packageName, versionCode, offerType);

	AndroidAppDeliveryData appDeliveryData = buyResponse.getPurchaseStatusResponse().getAppDeliveryData();

	String downloadUrl = appDeliveryData.getDownloadUrl();
	HttpCookie downloadAuthCookie = appDeliveryData.getDownloadAuthCookie(0);

	return executeDownload(downloadUrl, downloadAuthCookie.getName() + "=" + downloadAuthCookie.getValue());

    }

    /**
     * Posts given check-in request content and returns
     * {@link AndroidCheckinResponse}.
     */
    private AndroidCheckinResponse postCheckin(byte[] request) throws IOException {

	HttpEntity httpEntity = executePost(CHECKIN_URL, new ByteArrayEntity(request), new String[][] {
		{ "User-Agent", "Android-Checkin/2.0 (generic JRO03E); gzip" }, { "Host", "android.clients.google.com" },
		{ "Content-Type", "application/x-protobuffer" } });
	return AndroidCheckinResponse.parseFrom(httpEntity.getContent());
    }

    /**
     * This function is used for fetching download url and donwload cookie,
     * rather than actual purchasing.
     */
    private BuyResponse purchase(String packageName, int versionCode, int offerType) throws IOException {

	ResponseWrapper responseWrapper = executePOSTRequest(PURCHASE_URL, new String[][] { { "ot", String.valueOf(offerType) },
		{ "doc", packageName }, { "vc", String.valueOf(versionCode) }, });

	return responseWrapper.getPayload().getBuyResponse();
    }

    /**
     * Fetches url content by executing GET request with provided cookie string.
     */
    public InputStream executeDownload(String url, String cookie) throws IOException {

	String[][] headerParams = new String[][] { { "Cookie", cookie },
		{ "User-Agent", "AndroidDownloadManager/4.1.1 (Linux; U; Android 4.1.1; Nexus S Build/JRO03E)" }, };

	HttpEntity httpEntity = executeGet(url, null, headerParams);
	return httpEntity.getContent();
    }

    /**
     * Fetches the reviews of given package name by sorting passed choice.
     * 
     * Default values for offset and numberOfResult are "0" and "20"
     * respectively. These values are determined by Google Play Store.
     */
    public ReviewResponse reviews(String packageName, REVIEW_SORT sort, Integer offset, Integer numberOfResult)
	    throws IOException {
	ResponseWrapper responseWrapper = executeGETRequest(REVIEWS_URL,
		new String[][] { { "doc", packageName }, { "sort", (sort == null) ? null : String.valueOf(sort.value) },
			{ "o", (offset == null) ? null : String.valueOf(offset) },
			{ "n", (numberOfResult == null) ? null : String.valueOf(numberOfResult) } });

	return responseWrapper.getPayload().getReviewResponse();
    }

    /**
     * Uploads device configuration to google server so that can be seen from
     * web as a registered device!!
     * 
     * @see https://play.google.com/store/account
     */
    public UploadDeviceConfigResponse uploadDeviceConfig() throws Exception {

	UploadDeviceConfigRequest request = UploadDeviceConfigRequest.newBuilder()
		.setDeviceConfiguration(Utils.getDeviceConfigurationProto()).build();
	ResponseWrapper responseWrapper = executePOSTRequest(UPLOADDEVICECONFIG_URL, request.toByteArray(),
		"application/x-protobuf");
	return responseWrapper.getPayload().getUploadDeviceConfigResponse();
    }
    
    /**
     * Fetches the recommendations of given package name.
     * 
     * Default values for offset and numberOfResult are "0" and "20"
     * respectively. These values are determined by Google Play Store.
     */
    public ListResponse recommendations(String packageName, RECOMMENDATION_TYPE type, Integer offset, Integer numberOfResult)
	    throws IOException {
	ResponseWrapper responseWrapper = executeGETRequest(RECOMMENDATIONS_URL,
		new String[][] { { "c", "3" }, { "doc", packageName }, { "rt", (type == null) ? null : String.valueOf(type.value) },
			{ "o", (offset == null) ? null : String.valueOf(offset) },
			{ "n", (numberOfResult == null) ? null : String.valueOf(numberOfResult) } });

	return responseWrapper.getPayload().getListResponse();
    }

    /* =======================Helper Functions====================== */

    /**
     * Executes GET request and returns result as {@link ResponseWrapper}.
     * Standard header parameters will be used for request.
     * 
     * @see getHeaderParameters
     * */
    private ResponseWrapper executeGETRequest(String path, String[][] datapost) throws IOException {

	HttpEntity httpEntity = executeGet(path, datapost, getHeaderParameters(this.getToken(),null));
	return GooglePlay.ResponseWrapper.parseFrom(httpEntity.getContent());

    }

    /**
     * Executes POST request and returns result as {@link ResponseWrapper}.
     * Standard header parameters will be used for request.
     * 
     * @see getHeaderParameters
     * */
    private ResponseWrapper executePOSTRequest(String path, String[][] datapost) throws IOException {

	HttpEntity httpEntity = executePost(path, datapost, getHeaderParameters(this.getToken(), null));
	return GooglePlay.ResponseWrapper.parseFrom(httpEntity.getContent());

    }

    /**
     * Executes POST request and returns result as {@link ResponseWrapper}.
     * Content type can be specified for given byte array.
     */
    private ResponseWrapper executePOSTRequest(String url, byte[] datapost, String contentType) throws IOException {

	HttpEntity httpEntity = executePost(url, new ByteArrayEntity(datapost), getHeaderParameters(this.getToken(), contentType));
	return GooglePlay.ResponseWrapper.parseFrom(httpEntity.getContent());

    }

    /**
     * Executes POST request on given URL with POST parameters and header
     * parameters.
     */
    private HttpEntity executePost(String url, String[][] postParams, String[][] headerParams) throws IOException {

	List<NameValuePair> formparams = new ArrayList<NameValuePair>();

	for (String[] param : postParams) {
	    if (param[0] != null && param[1] != null) {
		formparams.add(new BasicNameValuePair(param[0], param[1]));
	    }
	}

	UrlEncodedFormEntity entity = new UrlEncodedFormEntity(formparams, "UTF-8");

	return executePost(url, entity, headerParams);
    }

    /**
     * Executes POST request on given URL with {@link HttpEntity} typed POST
     * parameters and header parameters.
     */
    private HttpEntity executePost(String url, HttpEntity postData, String[][] headerParams) throws IOException {
	HttpPost httppost = new HttpPost(url);

	if (headerParams != null) {
	    for (String[] param : headerParams) {
		if (param[0] != null && param[1] != null) {
		    httppost.setHeader(param[0], param[1]);
		}
	    }
	}

	httppost.setEntity(postData);

	return executeHttpRequest(httppost);
    }

    /**
     * Executes GET request on given URL with GET parameters and header
     * parameters.
     */
    private HttpEntity executeGet(String url, String[][] getParams, String[][] headerParams) throws IOException {

	if (getParams != null) {
	    List<NameValuePair> formparams = new ArrayList<NameValuePair>();

	    for (String[] param : getParams) {
		if (param[0] != null && param[1] != null) {
		    formparams.add(new BasicNameValuePair(param[0], param[1]));
		}
	    }

	    url = url + "?" + URLEncodedUtils.format(formparams, "UTF-8");
	}

	HttpGet httpget = new HttpGet(url);

	if (headerParams != null) {
	    for (String[] param : headerParams) {
		if (param[0] != null && param[1] != null) {
		    httpget.setHeader(param[0], param[1]);
		}
	    }
	}

	return executeHttpRequest(httpget);
    }

    /** Executes given GET/POST request */
    private HttpEntity executeHttpRequest(HttpUriRequest request) throws ClientProtocolException, IOException {

	HttpResponse response = getClient().execute(request);

	if (response.getStatusLine().getStatusCode() != 200) {
	    throw new GooglePlayException(new String(Utils.readAll(response.getEntity().getContent())));
	}

	return response.getEntity();
    }

    /**
     * Gets header parameters for GET/POST requests. If no content type is
     * given, default one is used!
     */
    private String[][] getHeaderParameters( String token, String contentType ) {

	return new String[][] {
		{ "Accept-Language", getLocalization()!=null?getLocalization():"en-EN" },
		{ "Authorization", "GoogleLogin auth=" + token },
		{ "X-DFE-Enabled-Experiments", "cl:billing.select_add_instrument_by_default" },
		{
			"X-DFE-Unsupported-Experiments",
			"nocache:billing.use_charging_poller,market_emails,buyer_currency,prod_baseline,checkin.set_asset_paid_app_field,shekel_test,content_ratings,buyer_currency_in_app,nocache:encrypted_apk,recent_changes" },
		{ "X-DFE-Device-Id", this.getAndroidID() },
		{ "X-DFE-Client-Id", "am-android-google" },
		{ "User-Agent",
			"Android-Finsky/3.10.14 (api=3,versionCode=8016014,sdk=15,device=GT-I9300,hardware=aries,product=GT-I9300)" },
		{ "X-DFE-SmallestScreenWidthDp", "320" }, { "X-DFE-Filter-Level", "3" },
		{ "Host", "android.clients.google.com" },
		{ "Content-Type", (contentType != null) ? contentType : "application/x-www-form-urlencoded; charset=UTF-8" } };
    }

    public String getToken() {
	return token;
    }

    public void setToken(String token) {
	this.token = token;
    }

    public String getAndroidID() {
	return androidID;
    }

    public void setAndroidID(String androidID) {
	this.androidID = androidID;
    }

    public String getSecurityToken() {
	return securityToken;
    }

    public void setSecurityToken(String securityToken) {
	this.securityToken = securityToken;
    }

    public HttpClient getClient() {
	return client;
    }

    /**
     * Sets {@link HttpClient} instance for internal usage of GooglePlayAPI.
     * It is important to note that this instance should allow concurrent connections.
     * 
     * @see getConnectionManager
     * 
     * @param client
     */
    public void setClient(HttpClient client) {
	this.client = client;
    }

    public String getEmail() {
	return email;
    }

    public void setEmail(String email) {
	this.email = email;
    }

	public String getLocalization() {
		return localization;
	}

	/**
	 * Localization string that will be used in each request to server. Using this option
	 * you can fetch localized informations such as reviews and descriptions. 
	 * <p>
	 * Note that changing this value has no affect on localized application list that 
	 * server provides. It depends on only your IP location.
	 * <p>
	 * 
	 * @param localization can be <b>en-EN, en-US, tr-TR, fr-FR ... (default : en-EN)</b>
	 */
	public void setLocalization(String localization) {
		this.localization = localization;
	}

}
